Skip to content

fix(plugin-security): refuse ADR-0068 built-in identity names at both position write doors - #17436

Merged
os-sales merged 6 commits into
mainfrom
claude/issue-15972-sys-position-reserved-names
Sep 10, 2026
Merged

fix(plugin-security): refuse ADR-0068 built-in identity names at both position write doors#17436
os-sales merged 6 commits into
mainfrom
claude/issue-15972-sys-position-reserved-names

Conversation

@claude

@claude claude Bot commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Fixes #15972

Clause-②: no

(declaration line written by the domain:services review seat, not by the implementer. It is added NOW, after the contract review passed and needs:contract-review was cleared from both carriers: while that label was on the PR it WAS the carrier (scripts/check-changeset-no-major.mjs reads the label as yes and it outranks the body), so a body line would have been redundant then and a no would have contradicted it. Measured on THIS diff: 4 added exports, all in the new src/objects/reserved-identity-names.ts, which the package barrel src/index.ts does not re-export (explicit named re-exports only, no objects/ line) and the package's exports map does not reach ("." only) ⇒ 0 net public surface; 0 new error codes (the refusal answers the already-registered VALIDATION_FAILED); and the accept set narrows — the whole change is a refusal. no + a minor changeset is a legal pair. ⛔ Do not delete this line.)

What this closes

sys_position.name and sys_user_position.position were unconstrained, so a tenant could mint a row spelling any framework-reserved built-in identity name — platform_admin, org_owner, org_admin, org_member. PR #15948 closed every in-repo READER that turned such a name into authority; it could not stop the row existing, and a reader is not an invariant.

Both declarations already SAID so, in prose. sys-position.object.ts: "Framework-reserved built-in identities (platform_admin / org_) … MUST NOT be repurposed by a tenant"*. resolve-authz-context.ts, from the other side: "Read the RUNG — never positions.includes(...); an ADR-0057 D4 sys_user_position row may spell that very name." Both were comments. This PR is the enforcement of sentences that were already in the tree.

Ruling implemented

Maintainer ruling via director seat, summon #20, decision batch #105 item 4, 2026-09-09 (comment 5595726426); maintainer reply verbatim: 「16934 关闭 ;其他同意」 = item 4 = A.

Ruled Here
Layer: the object layer, so every door (data API, seed, import) is covered validations[] on both objects — objectql's rule validator runs them on insert, by-id update and multi-row update
Reserved set: exactly the ADR-0068 built-in identity names, read from the spec constant, ⛔ not retyped, ⛔ not widened to org_* by pattern RESERVED_IDENTITY_NAMES is BUILTIN_IDENTITY_NAMES; the CEL list literal is GENERATED from it
The service write door reuses the same predicate rather than a second copy It reuses it by INHERITING it — see "the second door" below
Existing rows: refuse new writes only, no migration, read-only census scripts/measure-reserved-identity-name-census.mjs; nothing here rewrites a row

The second door, and why it grew no refusal of its own

The card's exposure is the ASSIGNMENT row, not the definition: sys_user_position.position is free text (it references sys_position.name by convention, not by lookup), the platform seeds a platform_admin catalog row in every organization, and a delegated administrator reaches the write — assertAssignmentWrite judges a position by the permission sets it DISTRIBUTES, and that seeded position distributes none, so boundSets.every(…) approves it vacuously. Re-measured on this branch, and still true (the gate APPROVES the assignment in the suite).

So the refusal has to cover that door. It does — at the object layer, on sys_user_position too. The delegated-admin gate is a hook on the same engine write, so an assignment that clears the gate still meets the refusal, and both doors answer with one error code.

⛔ A second refusal inside the gate was deliberately NOT added. It would carry that gate's own code (PERMISSION_DENIED) for a condition the object layer already names (VALIDATION_FAILED), and which of the two a caller saw would depend on hook order — two vocabularies for one condition, i.e. the "second copy" the ruling refuses. The predicate is shared; the refusal is single. This is the one place the implementation reads the ruling's wording rather than following it literally, so it is flagged for review.

Error code — no new code, and no packages/spec edit

Acceptance (a) contemplates a new ERROR_CODE_LEDGER row. None is taken: the refusal is authored as METADATA, so it carries VALIDATION_FAILED — objectql's ValidationError.code, already ledger-registered under the door that serves it, and already what every other object-level validation on this platform answers. There is no new stamp site in plugin-security, so check:error-code-provenance needs nothing. A dedicated code remains available as a follow-up if the maintainer wants the condition named on the wire; it would be a packages/spec change and route to that seat.

Two doors, two shapes

  • sys_position exempts the platform's own catalog provenance (managed_by of platform, or its legacy system spelling — the pair SYSTEM_ROW_PROVENANCE also maps to "the platform"). bootstrapBuiltinRoles seeds exactly these four names per organization on purpose. A package- or tenant-authored row is refused; a tenant cannot reach the exemption by claiming it, since managed_by is readonly and the admin-door provenance gate refuses a payload spelling platform/package outright.
  • sys_user_position takes no exemption. No writer in any package creates an assignment row spelling a built-in identity name — platform_admin standing comes from the unscoped admin_full_access grant, the org_* trio from sys_member.role.

⚠️ The rule is an INVARIANT, not a transition gate: a row that ALREADY spells a reserved name is refused on any edit until it is renamed — frozen, not bricked. Deliberate on a security invariant, and exactly the population the census reports.

验收备注

Acceptance, verbatim from the ruling, and where each half is met:

(a) a sys_position write spelling any ADR-0068 built-in identity name is refused at the object layer and at the service door with one error code, registered in ERROR_CODE_LEDGER if new.
Met. Both doors refuse with VALIDATION_FAILED; no new code, so no ledger row (see above). Pinned per name, on a real engine over a real SQL driver, for insert AND update, at both doors.

(b) negative control — every other name still writes.
Met, and it is asserted with the shapes a pattern-based guard would have swallowed: sales_manager, org_manager, platform_admin_deputy, hr_specialist all still write. In both ablation legs below the negative controls stayed GREEN while the refusals went red.

(c) the census lists existing colliding rows and modifies none.
Met. scripts/measure-reserved-identity-name-census.mjs opens no connection and takes no credentials: the default mode censuses DECLARATIONS in this repository, --rows FILE censuses a deployment from a read-only export. It separates the platform's own seeded catalog rows from real collisions, and refuses an input that never exported a table rather than reading it as zero. Its reserved set is parsed out of the declaring spec constant, with a control that throws instead of reporting a comfortable zero.

(d) zero reader changes (readers are #15948's, done).
Met — the diff touches no reader. Two object declarations, one new predicate module, one test, one census script, one changeset.

Fleet census of name-as-authority readers (the card's first deliverable), objectstack half, with a firing control:
node scripts/measure-reserved-identity-name-census.mjs over 2683 non-test source files returns exactly one candidate — packages/core/src/security/__tests__/resolve-authz-context.batch-equivalence.testkit.ts:262 name: 'org_admin', a test-kit fixture, not a shipped declaration. The FIRING CONTROL is the script's own --self-test: 5 controls, including a synthetic corpus where the scanner must hit platform_admin / org_admin and must NOT hit org_manager, platform_admin_x, or a label: key. ⛔ A zero (or a one) here is a reading over DECLARATIONS in this repository, never over a deployment — the script prints that sentence beside every count. The cloud half is out of reach from this session and remains the seam card the ruling files.

docs-drift-check advisory — read and answered, no page falsified. The bot lists 6 hand-written pages because they name a symbol this diff touched; the anchors are cross_field and managed_by, both string literals inside the new validations[] entries, so the pages match on the MECHANISM this change uses rather than on anything it changes about that mechanism. Checked against the falsifying shapes: no page enumerates which system objects carry validations, and none states that sys_position.name is unconstrained. validation.mdx / objectql/schema.mdx / seed-data.mdx document the rule TYPES; authorization.mdx / capabilities.mdx / permission-sets.mdx document managed_by provenance, and the exemption here reuses that vocabulary unchanged. ⛔ No docs edited, and no content/docs/releases/** page touched. The bot can only match shared identifiers, so the page that restates this invariant WITHOUT naming these symbols was checked by hand: content/docs/permissions/positions.mdx lists the four names as "Framework-seeded" and documents 'org_admin' in current_user.positions as the RLS/CEL membership test. Neither sentence is falsified — the first gains the enforcement it already implied, and the second is strictly safer now that the array can no longer carry a forged built-in name.

Ablation — C3, both negative pins

Mutation: severity: 'error''warning' on one rule at a time. That is the defect this card closes, spelled minimally: the rule stays declared, the metadata still lists it, the predicate still evaluates, and it refuses nothing — only error blocks the write. Predicted direction: RED. No dist is involved (both objects are imported by RELATIVE path inside their own package, so vitest resolves them to src/*.ts).

Leg On-disk proof Result
sys-position.object.ts removed severity: 'error' 1→0; injected severity: 'warning' 0→1 exit 1 — 7 failed / 17 passed; every sys_position refusal red, negative controls green
sys-user-position.object.ts removed 1→0; injected 0→1 exit 1 — 8 failed / 16 passed; every assignment refusal red, negative controls green

Restoration proved BY STATE, not by exit code, on each leg: git checkout HEAD -- PATH (never a bare git checkout --), then git diff HEAD empty AND git hash-object equal to the HEAD blob. Final state: git diff HEAD EMPTY. The script carried trap … EXIT INT TERM with absolute paths throughout.

A census figure this PR moves, and what it does NOT mean

scripts/tenant-audit-census.mjs's declaredObjects() counts every object literal in a *.object.ts carrying a snake_case name: string literal — it does not distinguish an object declaration from a nested one. It already counts the four actions[] names on sys_position (activate_position, deactivate_position, set_default_position, clone_position), so 298 was never a count of objects. ⛔ This PR does not add two objects; it adds two validations[] rules whose name is snake_case by contract (packages/spec requires /^[a-z_][a-z0-9_]*$/), and the counter tallies them the same way — 298 → 300.

Measured in ONE worktree with ONE node_modules, switching only HEAD: at origin/main (ab56ea3a1) the census reports 298 and check-tenant-audit-census --self-test exits 0; at this branch's head it reports 300 and the self-test exits 1. So the artefacts are regenerated here with the script's own documented --write, per 「碰生成物的 PR 入队前先同步 + 整体重生成」.

⚠️ Disclosed: that block regenerates whole, so it also refreshes two figures this diff did not cause — tracked non-test sources scanned 557 → 562 and engine-shaped types recognised 59 → 58 — drift accumulated since the block was last measured at 9cefca9a3. Only declared objects in the registry 298 → 300 is this PR's.

The gate itself is green either way: the corpus-scale figures are dated and deliberately not compared. What broke is the self-test case that rewords the prose claim off the page — it builds the string to replace from the LIVE count, so it silently no-ops once the page's tolerated drift becomes real, and then fails for the page rather than for the classifier it pins. ⛔ That latent fragility is #17437 and is not closed here; the checker, its fixtures and its expectations are untouched.

Verification

  • pnpm --filter @objectstack/plugin-security test110 files, 2127 tests, all passing (VERDICT command-exit 0).
  • pnpm --filter @objectstack/plugin-security typecheckVERDICT command-exit 0.
  • pnpm --filter '@objectstack/plugin-security^...' buildVERDICT command-exit 0.
  • pnpm lint — the WHOLE repo, eslint . --no-inline-config, exit 0. Not narrowed, so no narrowing evidence is owed. Run at ce329d7c.
  • Gates: derived from the actual diff with node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, re-derived after the last commit (unchanged), and reconciled — 79 derived, 79 run, 0 UNRUN. Exit codes captured before any pipe.
    • 74 green.
    • 2 caught real problems in this diff and are green after the fix: check:entry-guard (a hand-typed process.argv[1] entry guard in the census script) and check:where-matcher (the gate test double read a $and/$or key as a field name instead of refusing it).
    • 3 ⊘ NOT MEASURED — check:dual-build-cjs-loads, check:i18n, check:type-check-debt each exited 3 = PREREQUISITE NOT MET (they read a fully built workspace). Declared to CI's Build Core job; nothing was measured, and that is not a finding in either direction.
    • 1 pre-existing, unrelated at the time this list was measured (head 638d2b544, before the census artefacts were regenerated): node scripts/check-tenant-audit-census.mjs --self-test exited 1, while its PR-verdict sibling node scripts/check-tenant-audit-census.mjs was green. Cause: the live census counted 300 declared objects while content/docs/permissions/tenant-audit-census.mdx still said 298, and the gate deliberately does not compare that number ("value free, sentence required"), so only the self-test's exact-string .replace() noticed. ⚠️ Superseded on the current head: the regeneration described under 「A census figure this PR moves」 brought both artefacts to 300, so the page and the live count now agree. This diff still adds zero object declarations (git diff origin/main...HEAD | grep -c '^+.*ObjectSchema.create' = 0), and the underlying counter fragility is ⛔ not fixed here — it is check-tenant-audit-census --self-test exits 1 on main: the page states 298 declared objects, the live census counts 300, and the self-test needs the exact string the main gate deliberately ignores #17437.

Scope

No reader touched, no packages/spec edit, none of the five files fenced to PR #17332, and no edit to delegated-admin-gate.ts (the reasoning is under "the second door" above).


Generated by Claude Code

… position write doors

`sys_position.name` and `sys_user_position.position` were unconstrained, so a
tenant could mint a row spelling any framework-reserved built-in identity name
(`platform_admin`, `org_owner`, `org_admin`, `org_member`). PR #15948 closed
every in-repo reader that turned such a name into authority; it could not stop
the row existing, and an out-of-repo reader that reads the name instead of the
capability rung reopens the hole with nothing mechanical to catch it.

Both declarations now carry an object-level `validations[]` rule whose CEL list
literal is GENERATED from `BUILTIN_IDENTITY_NAMES` — the spec constant that
declares the identities — so the closed enumeration is imported, never retyped
and never widened to an `org_*` pattern. Object-level validations are evaluated
by the engine on insert, by-id update and multi-row update, so the data API, the
seeders and metadata import are all covered by ONE refusal carrying ONE code
(`VALIDATION_FAILED`).

`sys_position` exempts the platform's own catalog provenance (`managed_by` of
`platform`, or its legacy `system` spelling) because `bootstrapBuiltinRoles`
seeds exactly these names; `sys_user_position` takes no exemption at all, since
no writer in any package creates an assignment row spelling one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ToDPcx9AESFubJkDiFMtKW
…y-name rows

`scripts/measure-reserved-identity-name-census.mjs` reports rows that already
stand on an ADR-0068 built-in identity name and rewrites none of them, per the
maintainer ruling («refuse new writes only. No migration. A read-only census
reports existing colliding rows to the maintainer»).

Two modes: the default censuses DECLARATIONS in this repository; `--rows FILE`
censuses a deployment from a read-only export, separating the platform's own
seeded catalog rows from real collisions and refusing an input that never
exported a table rather than reading it as zero. The reserved set is parsed out
of the spec constant that declares it, with a control that throws instead of
reporting a comfortable zero when the parse finds nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ToDPcx9AESFubJkDiFMtKW
…voked-as, and make the gate double refuse combinators

Two gate findings on this branch's own diff:

- `check:entry-guard` — the census script carried a hand-typed
  `import.meta.url === file://${process.argv[1]}` guard, which answers false
  through a symlink and silently does nothing. Routed through
  `scripts/invoked-as.mjs`'s `isEntrypoint`, like every other `scripts/` entry.
- `check:where-matcher` — the DelegatedAdminGate test double read a `$and` /
  `$or` key as a field name instead of refusing it, the silently-wrong shape:
  every row would fail the lookup and the assertion would pass for a reason
  unrelated to what it measures. It now throws on any combinator it does not
  implement, matching the sibling double in `delegated-admin-gate.test.ts`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ToDPcx9AESFubJkDiFMtKW
@github-actions

github-actions Bot commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-security, touching 16 documentable anchor(s).

14 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/automation/approvals.mdx (via sys_user_position (symbol, a field of const object enObjects; a field of const object esESObjects; a field of const object jaJPObjects; a field of const object zhCNObjects))
  • content/docs/data-modeling/objects.mdx (via sys_user_position (symbol, a field of const object enObjects; a field of const object esESObjects; a field of const object jaJPObjects; a field of const object zhCNObjects))
  • content/docs/data-modeling/seed-data.mdx (via cross_field (literal, a string literal in validations))
  • content/docs/data-modeling/validation.mdx (via cross_field (literal, a string literal in validations))
  • content/docs/deployment/environment-variables.mdx (via sys_position (symbol, a field of const object enObjects; a field of const object esESObjects; a field of const object jaJPObjects; a field of const object zhCNObjects), sys_user_position (symbol, a field of const object enObjects; a field of const object esESObjects; a field of const object jaJPObjects; a field of const object zhCNObjects))
  • content/docs/permissions/administrator-guide.mdx (via sys_user_position (symbol, a field of const object enObjects; a field of const object esESObjects; a field of const object jaJPObjects; a field of const object zhCNObjects))
  • content/docs/permissions/authentication.mdx (via sys_user_position (symbol, a field of const object enObjects; a field of const object esESObjects; a field of const object jaJPObjects; a field of const object zhCNObjects))
  • content/docs/permissions/authorization.mdx (via sys_position (symbol, a field of const object enObjects; a field of const object esESObjects; a field of const object jaJPObjects; a field of const object zhCNObjects), sys_user_position (symbol, a field of const object enObjects; a field of const object esESObjects; a field of const object jaJPObjects; a field of const object zhCNObjects), managed_by (literal, a string literal in validations))
  • content/docs/permissions/capabilities.mdx (via managed_by (literal, a string literal in validations))
  • content/docs/permissions/delegated-administration.mdx (via sys_position (symbol, a field of const object enObjects; a field of const object esESObjects; a field of const object jaJPObjects; a field of const object zhCNObjects), sys_user_position (symbol, a field of const object enObjects; a field of const object esESObjects; a field of const object jaJPObjects; a field of const object zhCNObjects))
  • content/docs/permissions/permission-sets.mdx (via sys_user_position (symbol, a field of const object enObjects; a field of const object esESObjects; a field of const object jaJPObjects; a field of const object zhCNObjects), managed_by (literal, a string literal in validations))
  • content/docs/permissions/positions.mdx (via sys_position (symbol, a field of const object enObjects; a field of const object esESObjects; a field of const object jaJPObjects; a field of const object zhCNObjects), sys_user_position (symbol, a field of const object enObjects; a field of const object esESObjects; a field of const object jaJPObjects; a field of const object zhCNObjects))
  • content/docs/protocol/backward-compatibility.mdx (via sys_position (symbol, a field of const object enObjects; a field of const object esESObjects; a field of const object jaJPObjects; a field of const object zhCNObjects))
  • content/docs/protocol/objectql/schema.mdx (via cross_field (literal, a string literal in validations))

10 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/index.mdx (via sys_position (symbol, a field of const object enObjects; a field of const object esESObjects; a field of const object jaJPObjects; a field of const object zhCNObjects))
  • content/docs/releases/v12.mdx (via managed_by (literal, a string literal in validations))
  • content/docs/releases/v13.mdx (via SysPosition (symbol, a top-level const object), SysUserPosition (symbol, a top-level const object), sys_position (symbol, a field of const object enObjects; a field of const object esESObjects; a field of const object jaJPObjects; a field of const object zhCNObjects), sys_user_position (symbol, a field of const object enObjects; a field of const object esESObjects; a field of const object jaJPObjects; a field of const object zhCNObjects))
  • content/docs/releases/v14.mdx (via sys_position (symbol, a field of const object enObjects; a field of const object esESObjects; a field of const object jaJPObjects; a field of const object zhCNObjects), sys_user_position (symbol, a field of const object enObjects; a field of const object esESObjects; a field of const object jaJPObjects; a field of const object zhCNObjects))
  • content/docs/releases/v15.mdx (via sys_position (symbol, a field of const object enObjects; a field of const object esESObjects; a field of const object jaJPObjects; a field of const object zhCNObjects), sys_user_position (symbol, a field of const object enObjects; a field of const object esESObjects; a field of const object jaJPObjects; a field of const object zhCNObjects), managed_by (literal, a string literal in validations))
  • content/docs/releases/v16.mdx (via sys_user_position (symbol, a field of const object enObjects; a field of const object esESObjects; a field of const object jaJPObjects; a field of const object zhCNObjects))
  • content/docs/releases/v17/17-0.mdx (via sys_user_position (symbol, a field of const object enObjects; a field of const object esESObjects; a field of const object jaJPObjects; a field of const object zhCNObjects), cross_field (literal, a string literal in validations))
  • content/docs/releases/v17/17-1.mdx (via sys_position (symbol, a field of const object enObjects; a field of const object esESObjects; a field of const object jaJPObjects; a field of const object zhCNObjects))
  • content/docs/releases/v17/17-2.mdx (via sys_position (symbol, a field of const object enObjects; a field of const object esESObjects; a field of const object jaJPObjects; a field of const object zhCNObjects))
  • content/docs/releases/v17/17-4.mdx (via sys_user_position (symbol, a field of const object enObjects; a field of const object esESObjects; a field of const object jaJPObjects; a field of const object zhCNObjects))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 100 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 15 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json fd62a66b7c48be167deb74467ca93e6ad3223f38packageMentionDocs.

Which tree this was computed on

This run read content/docs from e2df5f6bcf5cf9b081497948f862b91907968222 — the merge of head 03b02318646e89e2447a1ba7f94ff608f5a78f0a into base fd62a66b7c48be167deb74467ca93e6ad3223f38, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin e2df5f6bcf5cf9b081497948f862b91907968222 && git checkout e2df5f6bcf5cf9b081497948f862b91907968222
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin fd62a66b7c48be167deb74467ca93e6ad3223f38 03b02318646e89e2447a1ba7f94ff608f5a78f0a && git checkout -B drift-repro fd62a66b7c48be167deb74467ca93e6ad3223f38 && git merge --no-ff 03b02318646e89e2447a1ba7f94ff608f5a78f0a

node scripts/docs-audit/affected-docs.mjs --json fd62a66b7c48be167deb74467ca93e6ad3223f38

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs fd62a66b7c48be167deb74467ca93e6ad3223f38 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…ation messages

`node scripts/check-i18n-bundles.mjs --write`, nothing else in this commit.

The two `validations[]` entries added on this branch carry an authored `message`,
which the rule validator resolves through i18n at refusal time
(`objects.<object>._validations.<rule>.message`), so the package's bundles were
behind the schema — `check:i18n` reported `plugins/plugin-security: 7 bundle(s)
drifted` on CI, which is the measurement this branch could not take locally
until the gate's build prerequisite was cleared.

Exactly the gate's designed output: `en` is rewritten from source (it is a copy,
not a translation), and merge mode adds the new keys to the translated locales
filled with the source text, which still needs translating.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ToDPcx9AESFubJkDiFMtKW
@claude

claude Bot commented Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

Docs-drift advisory (re-run on 638d2b544) — the list grew 6 → 14. All 8 new rows are generated-artifact echo. ⛔ No action

domain:services seat, session session_01ToDPcx9AESFubJkDiFMtKW, 2026-09-10T14:4xZ.

Why it grew, and why that is not a signal

The patch round regenerated 7 i18n bundles under packages/plugins/plugin-security/src/translations/. Read the new rows' own anchor reasons and they all say the same thing:

sys_user_position (symbol, a field of const object enObjects; a field of const object esESObjects; a field of const object jaJPObjects; a field of const object zhCNObjects)

enObjects / esESObjects / jaJPObjects / zhCNObjects are the const objects inside those generated bundles. The regeneration added two keys — objects.sys_position._validations.reserved_identity_name.message and objects.sys_user_position._validations.reserved_identity_position.message — which made the symbols sys_position and sys_user_position "touched by this diff" in the anchor index.

⇒ Every one of the 8 new pages is listed because a generated translation file gained a message key naming those objects. ⛔ Nothing those pages document — the objects' shape, their fields, their permissions semantics — changed. The advisory is precision-first about anchors, and it is being precise here; the anchor is simply a generated artifact.

⭐ The original 6 rows (via the cross_field / managed_by literals) were already answered by the delivering dev with a per-page reading, including a by-hand check of content/docs/permissions/positions.mdx — the page the tool structurally cannot list, because it restates the invariant in prose without naming any changed symbol. That answer stands and is not disturbed by this re-run.

⛔ Release-owned pages: the red line held, verified on the new head

This re-run lists 10 read-only content/docs/releases/** pages. Measured on PR #17436's file page at 14:4xZ, head 638d2b544:

count
changed files 13
content/docs/releases/** 0
any content/docs/** 0
governed surface (docs/adr/**, .claude/**, skills/**, AGENTS.md, CLAUDE.md) 0

⇒ The guardrail is listed, ⛔ not crossed. The 7 added files are exactly the regenerated bundles and nothing else.

⇒ Advisory answered, no action, and ⛔ this PR is not widened on account of it.


Generated by Claude Code

@claude

claude Bot commented Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

Lint & Repo Gates — root-caused, and the remedy lands outside this PR's surface

The check: Lint & Repo Gatesnode scripts/check-tenant-audit-census.mjs --self-test, exit 1, 1 of 19 case(s) failed on an unenforced prose claim reworded off the page IS a finding.

It is this PR's trigger. My first report classified it "pre-existing, unrelated". That classification does not survive measurement and I withdraw it. Measured in ONE worktree with ONE node_modules, switching only HEAD:

HEAD declaredObjects --self-test
origin/main (ab56ea3a1) 298 exit 0
this branch (638d2b544) 300 exit 1

Mechanism. declaredObjects() in scripts/tenant-audit-census.mjs walks every *.object.ts file and counts each object literal carrying a snake_case name: string literal — it does not distinguish an object declaration from a nested one. The two validations[] rules this PR adds are counted as declared objects:

reserved_identity_name      -> packages/plugins/plugin-security/src/objects/sys-position.object.ts
reserved_identity_position  -> packages/plugins/plugin-security/src/objects/sys-user-position.object.ts

298 + 2 = 300. ⛔ Not the i18n bundles: the count was already 300 before those were regenerated, and the walk reads *.object.ts only.

The over-match itself predates this PR — the same rule already counts the four actions[] names on sys_position (activate_position, clone_position, …), so 298 was never a count of objects. What this PR did is move the number, and the gate's own design is why that breaks only the self-test: the corpus-scale figures are dated and deliberately not compared (the bare gate is green at 300 — check-tenant-audit-census: OK … 23 prose figures held to the census), while the self-test's mutation builds the string to replace from the LIVE count, so it silently becomes a no-op once the tolerated drift becomes real.

The remedy, measured green and then withdrawn from this branch. node scripts/tenant-audit-census.mjs --write (the script's own documented mode — no checker, fixture or expectation is touched) plus the one prose figure that sits outside the GENERATED block:

  • content/docs/permissions/tenant-audit-census.mdx — line 87 prose Across 298 declared objects300; and the generated block: declared objects in the registry 298 → 300, tracked non-test sources scanned 557 → 562, engine-shaped types recognised 59 → 58, Measured on 2026-09-07 9cefca9a3 → 2026-09-10 638d2b544.
  • docs/audits/2026-08-tenant-audit-write-call-sites.counts.md — the same generated block.

With that applied locally, --self-test exits 0 and the bare gate stays 0.

Not pushed. content/docs/** is domain:devx, outside this card's declared file surface, so it is handed to the PM to route rather than carried here. This comment is the standing-down record for that hand-off.

The rest of the job is clear. Lint & Repo Gates aborts at the first failure, so a red there is a lower bound. With the docs remedy applied locally I re-ran every one of the 182 gate invocations in that job's step list, exit code captured before any pipe: 182 of 182 exit 0. ⇒ this docs count is the only thing between this PR and a green Lint & Repo Gates.

#17437 stays open and is unaffected: it is the card for the checker fragility itself — a self-test whose mutation depends on a figure its own gate declares unenforced will re-break for the next author who adds a snake_case name: to any *.object.ts. Fixing the count clears this PR; it does not close that.


Generated by Claude Code

`node scripts/tenant-audit-census.mjs --write` — the script's own documented
mode — plus the one prose figure that mirrors the generated count and sits
outside the GENERATED block.

ROOT CAUSE, and it is this branch's. `declaredObjects()` in
`scripts/tenant-audit-census.mjs` walks every `*.object.ts` and counts EACH
object literal carrying a snake_case `name:` string literal; it does not
distinguish an object declaration from a nested one. The four `actions[]` names
on `sys_position` (`activate_position`, `clone_position`, …) were already in
the tally before this branch, so 298 was never a count of objects. The two
`validations[]` rules added here are counted the same way, moving it to 300 —
and a rule name cannot dodge it, since `packages/spec` requires it to be
snake_case.

Measured in ONE worktree with ONE `node_modules`, switching only HEAD: at
`origin/main` (`ab56ea3a1`) the census reports 298 and
`check-tenant-audit-census --self-test` exits 0; at this branch's head it
reports 300 and the self-test exits 1.

The gate itself is green either way — the corpus-scale figures are dated and
explicitly NOT compared. What breaks is the self-test case that rewords the
prose claim off the page: it builds the string to replace from the LIVE count,
so it silently becomes a no-op once the page's tolerated drift becomes real,
and the case then fails for the page rather than for the classifier it pins.
That latent fragility is #17437 and is NOT closed by this commit.

DISCLOSED: the block regenerates whole, so it also refreshes two figures this
diff did not cause — `tracked non-test sources scanned` 557 -> 562 and
`engine-shaped types recognised` 59 -> 58 — drift accumulated since the block
was last measured at `9cefca9a3`.

⛔ The checker, its fixtures and its expectations are untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ToDPcx9AESFubJkDiFMtKW
@claude

claude Bot commented Sep 10, 2026

Copy link
Copy Markdown
Contributor Author

Correction to the comment above — ⛔ this is NOT waiting on another lane

The PM routed the remedy back into this PR, and it is pushed (03b0231). The comment above called itself a standing-down record for a hand-off; there is no hand-off. ⛔ Do not read this PR as blocked on domain:devx.

Why the surface extension is a distinction, not a convenience — the PM's reasoning, recorded here so the next reader does not re-derive it: content/docs/** is normally devx's because the usual case is authored guidance falsified by behaviour, where someone has to decide what the prose should now say. This is not that. It is a generated count that this PR's own diff moved, emitted by the script's own documented --write; no checker, fixture, expectation or authored sentence is being decided. The repo's doctrine puts regeneration on the PR that moved the artefact (「碰生成物的 PR 入队前先同步 + 整体重生成」), and parking a priority:p1 security fix behind another lane's queue for a number nobody else moved is the worse outcome.

What landed, exactly and nothing else:

  • content/docs/permissions/tenant-audit-census.mdx — the GENERATED block, plus the single line-87 prose figure Across 298 declared objects300, which mirrors the generated count.
  • docs/audits/2026-08-tenant-audit-write-call-sites.counts.md — the same generated block.

⚠️ Disclosed: the block regenerates whole, so it also refreshes two figures this diff did not cause — tracked non-test sources scanned 557 → 562 and engine-shaped types recognised 59 → 58, drift since the block was last measured at 9cefca9a3. Only declared objects in the registry 298 → 300 is this PR's. It is stated in the PR body and in the changeset too, so a reviewer does not have to discover it.

After the edit: check-tenant-audit-census --self-test exits 0 and the bare gate exits 0. ⛔ The checker, its fixtures and its expectations are untouched, no ledger was raised, and nothing was skipped.

#17437 stays open and gains this mechanism: a self-test whose mutation string is built from a live figure its own gate declares unenforced will re-break for the next author who adds a snake_case name: to any *.object.ts. Fixing the count clears this PR; it does not close that card.


Generated by Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 34501527873 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Dogfood Regression Gate (2/3) — 失败步骤: Boot example apps and exercise real user flows

    @objectstack/dogfood:test:  FAIL   isolated  test/schedule-sweep-organization-scope.dogfood.test.ts > dogfood [sqlite-wasm]: a time-relative sweep selects inside its declared organization (#16659)
      ↳ 失败原因: (这条 FAIL 之后 12 行内没有可识别的原因行 —— 点进 job 看)
    @objectstack/dogfood:test:  FAIL   isolated  test/schedule-sweep-organization-scope.dogfood.test.ts > dogfood [memory]: a time-relative sweep selects inside its declared organization (#16659)
      ↳ 失败原因: (这条 FAIL 之后 12 行内没有可识别的原因行 —— 点进 job 看)
    
  • Dogfood Regression Gate (3/3) — 失败步骤: Boot example apps and exercise real user flows

    @objectstack/dogfood:test:  FAIL   isolated  test/schedule-acting-organization.dogfood.test.ts > dogfood [sqlite-wasm]: a scheduled run executes as its declared organization (#16659)
      ↳ 失败原因: (这条 FAIL 之后 12 行内没有可识别的原因行 —— 点进 job 看)
    @objectstack/dogfood:test:  FAIL   isolated  test/schedule-acting-organization.dogfood.test.ts > dogfood [memory]: a scheduled run executes as its declared organization (#16659)
      ↳ 失败原因: (这条 FAIL 之后 12 行内没有可识别的原因行 —— 点进 job 看)
    
  • Test Core (3/6) — 失败步骤: Run this shard's tests

    @objectstack/client:test:  FAIL  src/organization-invite-role-default.test.ts > #16582 organizations.invite defaults role to member > ① the two-argument form is accepted and lands a pending member inv
      ↳ 失败原因: @objectstack/client:test: Error: Test timed out in 5000ms.
    

↳ 失败原因 是判读的关键:超时Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

⚠️ 断言这一侧有一类例外,判据是断言在测什么,不是它是不是 AssertionError 断言的对象是产品行为(一个值、一个形状、一次拒收)⇒ 照上面读:真实的行为改变,去查,⛔ 不要重排掉;
断言的对象是这次实验自身的有效性前提(跑完的耗时、负载下的先后、任何只在时间预算内才成立的条件)⇒ 它跟超时是同一类,同样对负载敏感,重排一次是合法的判别手段。
识别是机械的:断言的消息或它比较的值本身点名了一段时长、一个时间戳、一个耗时计数。实测过的一对 —— AssertionError: SecurityPlugin.init() ran: expected false to be true 测的是产品行为(真回归);
AssertionError: this run took over a second, so second-precision stamps could have differed too: expected 1006 to be less than 1000 测的是实验前提:它守护的那条不变式当时是绿的,同一个 head 原样重排一次即成功。
穿着 AssertionError 外衣的时间测量,仍然是时间测量。(⛔ 这只改「怎么读一次红」,不改「哪些测试可以重排」——后者由别处管。)

跨 PR 相同签名(24h,按失败测试文件聚合):

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 3 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

Copy link
Copy Markdown
Collaborator

Merge-queue build 34501527873 failed — triaged, and it is ⛔ NOT this PR's. Standing down without spending a re-queue, because the root has already left the queue and GitHub has re-queued this PR on a clean base.

domain:services seat (session session_01ToDPcx9AESFubJkDiFMtKW), 2026-09-10T16:39Z. ⛔ Nothing pushed, nothing skipped, nothing quarantined, no re-queue spent.

What failed, and the two controls that place it

failing test REASON line reading
test/schedule-sweep-organization-scope.dogfood.test.ts (sqlite-wasm + memory) assertion (no timeout) real behaviour, ⛔ not timing
test/schedule-acting-organization.dogfood.test.ts (sqlite-wasm + memory) assertion (no timeout) real behaviour, ⛔ not timing
src/organization-invite-role-default.test.ts (@objectstack/client) Error: Test timed out in 5000ms. load/timing, per the triage workflow's own stated rule

Control 1 — the file surface. This PR's diff, counted mechanically: 0 changed files matching service-automation, trigger-schedule, dogfood, packages/client or schedule, against a positive control of 11 plugin-security files. ⇒ It cannot have reached any of the three.

Control 2 — the base branch. On origin/main @ 65ad77d5aea371d67cee8cac2aa4634457fc409b, newest run per check name: Dogfood Regression Gate and all three shards (1/3, 2/3, 3/3) completed / success. ⇒ Those two tests pass on main. Both were introduced by ecdfc9411 (#17334), which is on main and green on them.

⇒ Not this PR's, and ⛔ not main-red either. By elimination it is a semantic conflict inside the queue stack — the third case on the triage workflow's own checklist.

The root, and why the inherited rows were bystanders

The queue-flake anchor's stack column names it: #17444 is S1 · root; #17436, #17455 and #17465 are S1 · inherited. GitHub builds each queued entry on top of the previous, so a single deterministic break ejects everything behind it and the raw victim count climbs with queue depth. ⇒ Four "victim" PRs, one independent hit.

#17444 is fix(objectql)!: refuse a field whose type is absent or not a FieldType member at the registration door (#16319) — a refusal added at the registration door, which can break a dogfood app's boot without touching a single dogfood file (it touches none). And the queue runs the full suite while PR-side CI runs only the affected subset, so exactly this class is invisible until the queue.

Full evidence and reasoning posted to the anchors: #17479 (with the complete reading) and #17481 (same build, same shape, pointer). ⛔ Stated there as the place to look, not as a proven cause — confirming it is the root's owner's read.

Why no fix was ported and no re-queue was spent

  • Nothing to port. The break is between another PR's engine-side refusal and a third PR's dogfood fixtures. This PR touches neither side; porting anything here would be widening it for a defect it did not cause.
  • The re-run is unspent, and stays unspent — 「修好前重排只会再烧一轮全队列」, and there were five entries queued. ⛔ A blind re-queue rebuilds all of them.
  • And it is no longer needed: re-read at 16:39Z, fix(objectql)!: refuse a field whose type is absent or not a FieldType member at the registration door (#16319) #17444 has left the queue (its queue branch is gone; the PR is open and mergeable_state: clean), and this PR was automatically re-queued on a fresh basegh-readonly-queue/main/pr-17436-010c48a09d…, which no longer carries the root. ⇒ The condition the triage checklist names (「等前面的 PR 落地或失败出队后再重排一次」) was met by the queue itself.

Watching this build. If it fails again on a base without #17444, that result is this PR's to root-cause and this seat will treat it as such. ⛔ No test will be skipped, disabled or quarantined to reach green, and no empty commit will be pushed to kick CI.

domain:services 执行席 · session_01ToDPcx9AESFubJkDiFMtKW · 2026-09-10T16:39Z · controls on origin/main @ 65ad77d5a, queue re-read @ e758131b3


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A tenant can mint a sys_user_position row spelling any built-in identity name — PR #15948 closed every reader, nothing stops the row

2 participants